// Package parser contains functions to parse data out of 4chan boards and pages.
// This is updated for the April 2012 revision to the board HTML.
package parser

import (
    "bytes"
    "errors"
    "exp/html"
    "fmt"
    "io"
    "log"
    "strconv"
    "strings"
    "time"
    "unicode"
    "yotsuba"
)

type rangeFunction func(r rune) bool

// Function mapRange maps an IsX function from the unicode package across
// all Runes in a string.
func mapRange(f rangeFunction, str string) bool {
    for _, c := range str {
        if !f(c) {
            return false
        }
    }
    return true
}

func getAttr(token html.Token, key string) (string, error) {
    for _, a := range token.Attr {
        if a.Key == key {
            return a.Val, nil
        }
    }
    return "", fmt.Errorf("Attribute %s not found on token.", key)
}

func readSize(str string) (size int) {
    split := strings.Split(str, " ")
    num, _ := strconv.ParseFloat(split[0], 64)

    switch split[1] {
    case "KB":
        return int(num * 1024)
    case "MB":
        return int(num * 1024 * 1024)
    }
    return int(num)
}

// Scan board page and return slice of threads
func ScanPage(body *io.ReadCloser, url string, pageNo int) []*yotsuba.Thread {
    var threads []*yotsuba.Thread
    page := html.NewTokenizer(*body)

    eof := false
    for !eof {
        token := page.Token()
        page.Next()
        if page.Err() == io.EOF {
            eof = true
        } else if page.Err() != nil {
            log.Fatal(page.Err())
        }

        class, err := getAttr(token, "class")
        if err != nil || class != "thread" {
            continue
        }

        id, err := getAttr(token, "id")
        if err != nil {
            continue
        }
        threadNo := strings.Replace(id, "t", "", -1)
        t := new(yotsuba.Thread)
        t.Id, _ = strconv.Atoi(threadNo)
        t.Url = url + "res/" + threadNo
        t.Page = pageNo
        threads = append(threads, t)
    }
    return threads
}

// Big scary parser function that scrapes post data from threads.
func ScanThread(thread *yotsuba.Thread, body *io.ReadCloser) (*yotsuba.Thread,
    error) {
    page := html.NewTokenizer(*body)
    posts := []*yotsuba.Post{}
    current := new(yotsuba.Post)
    spoilerDepth := 0
    redDepth := 0

    // postBody tracks whether we're reading text or markup from a comment.
    // complete determines whether we've successfully read to the bottom of a
    // well-formed page
    var eof, postBody, highlight, complete bool
    firstPost := true

    // Field points to whichever post field text will be written to.
    // buf is for aggregating larger, more complicated post body text
    // with possible internal markup over multiple iterations of the loop.
    var field *string
    var postTime string
    buf := new(bytes.Buffer)

    for !eof {
        token := page.Token()
        page.Next()
        if page.Err() == io.EOF {
            eof = true
        } else if page.Err() != nil {
            log.Fatal(page.Err())
        }
        switch token.Type {

        case html.StartTagToken:
            if token.Data == "div" {
                class, err := getAttr(token, "class")
                if err != nil {
                    continue
                }

                switch class {
                // post number divs (reply, highlighted reply, and admin
                case "post reply highlightPost":
                    highlight = true
                    fallthrough
                case "post op":
                    fallthrough
                case "post reply":
                    // Finalize previous post.
                    if !firstPost {
                        // TODO: this is for html. Other boards use data-utc
                        // attribute. Remove when no longer needed.
                        if postTime != "" {
                            current.Time, _ = time.Parse(
                                yotsuba.YotsubaTime+"MST", postTime+"EST")
                            postTime = ""
                        }
                        posts = append(posts, current)
                    }

                    // Start new post.
                    firstPost = false
                    id, err := getAttr(token, "id")
                    if err != nil {
                        continue
                    }
                    current = new(yotsuba.Post)

                    notDigit := func(r rune) bool {
                        return !unicode.IsDigit(r)
                    }
                    current.Id, _ = strconv.Atoi(
                        strings.TrimFunc(id, notDigit))
                    if highlight {
                        current.Highlight = true
                        highlight = false
                    }
                case "file":
                    current.Img = new(yotsuba.Image)
                }
                continue
            }

            if token.Data == "span" {
                id, err := getAttr(token, "id")
                if err == nil && id == "navbotr" {
                    complete = true
                    continue
                }

                title, err := getAttr(token, "title")
                if err == nil {
                    current.ImgName = title
                    continue
                }

                class, err := getAttr(token, "class")
                if err != nil {
                    continue
                }
                switch class {
                case "nameBlock capcodeMod":
                    current.Auth = yotsuba.Mod
                case "nameBlock capcodeAdmin":
                    current.Auth = yotsuba.Admin

                case "name":
                    field = &current.Name
                case "postertrip":
                    field = &current.Trip
                case "subject":
                    field = &current.Subject
                case "dateTime":
                    // TODO: postTime var is only here because /htmlnew/ is
                    // now missing data-utc and is thus divergent from the rest
                    // of 4chan. Remove this once /htmlnew/ is gone or I no
                    // longer need it for reference.
                    utc, err := getAttr(token, "data-utc")
                    if err == nil {
                        unixTimeSec, _ := strconv.ParseInt(utc, 10, 64)
                        current.Time = time.Unix(unixTimeSec, 0)
                    } else {
                        field = &postTime
                    }
                case "spoiler":
                    if postBody {
                        fmt.Fprint(buf, "[spoiler]")
                        spoilerDepth++
                    }
                }

                continue
            }

            if token.Data == "blockquote" {
                class, err := getAttr(token, "class")
                if err != nil || class != "postMessage" {
                    continue
                }
                postBody = true
                field = &current.Text
                continue
            }

            if token.Data == "a" {
                class, err := getAttr(token, "class")
                if err != nil {
                    continue
                }
                switch class {
                case "useremail":
                    href, err := getAttr(token, "href")
                    if err == nil {
                        current.Mail = strings.Replace(href, "mailto:", "", -1)
                    }

                case "fileThumb imgspoiler":
                    current.Spoiler = true
                    fallthrough
                case "fileThumb":
                    href, err := getAttr(token, "href")
                    if err != nil {
                        continue
                    }
                    current.ImgUrl = "http:" + href
                }
                continue
            }

            if token.Data == "strong" && postBody {
                style, err := getAttr(token, "style")
                if err == nil && style == "color: red;" {
                    fmt.Fprint(buf, "[color=red]")
                    redDepth++
                    current.RedText = true
                }
                continue
            }

            if token.Data == "img" {
                alt, err := getAttr(token, "alt")
                if err != nil && alt == "File deleted." {
                    current.FileDeleted = true
                    continue
                }
            }

            if postBody && token.Data == "br" {
                fmt.Fprint(buf, "\n")
            }

        case html.TextToken:
            // File size and dimensions look like: -(115 KB, 1024x768
            // or : -(Spoiler Image, 115 KB, 1024x768
            if !postBody && strings.HasPrefix(token.Data, "-(") {
                imgInfo := token.Data[2:]
                if strings.HasPrefix(imgInfo, "Spoiler Image, ") {
                    imgInfo = strings.Replace(imgInfo,
                        "Spoiler Image, ", "", 1)
                }
                imgData := strings.Split(imgInfo, ", ")
                imgRes := strings.Split(imgData[1], "x")
                current.Img.FullX, _ = strconv.Atoi(imgRes[0])
                current.Img.FullY, _ = strconv.Atoi(imgRes[1])
            }

            if field != nil {
                fmt.Fprint(buf, yotsuba.EscapeBBCode.Replace(token.Data))
            }
            continue

        case html.EndTagToken:
            if token.Data == "blockquote" && field != nil {
                *field = buf.String()
                field = nil
                postBody = false
                buf.Reset()
                continue
            }

            if token.Data == "span" {
                if postBody {
                    if spoilerDepth > 0 {
                        fmt.Fprint(buf, "[/spoiler]")
                        spoilerDepth--
                    }
                    continue
                }

                if field != nil {
                    *field = buf.String()
                    field = nil
                    buf.Reset()
                }
                continue
            }

            if token.Data == "strong" && redDepth > 0 && postBody {
                fmt.Fprint(buf, "[/color]")
                redDepth--
                continue
            }

        case html.SelfClosingTagToken:
            if token.Data == "img" {
                alt, err := getAttr(token, "alt")
                if err != nil {
                    continue
                }

                if alt == "Sticky" {
                    thread.Sticky = true
                    continue
                }
                if alt == "Closed" {
                    thread.Locked = true
                    continue
                }

                md5, err := getAttr(token, "data-md5")
                if err != nil {
                    continue
                }
                src, err := getAttr(token, "src")
                if err != nil {
                    continue
                }
                thumbStyle, err := getAttr(token, "style")
                if err != nil {
                    continue
                }

                // style="height: 251px; width: 210px;"
                tsSplit := strings.Split(thumbStyle, " ")

                current.Img.Md5 = md5
                current.Img.Size = readSize(alt)
                current.ThumbUrl = "http:" + src
                current.Img.ThumbX, _ = strconv.Atoi(
                    strings.Replace(tsSplit[1], "px;", "", -1))
                current.Img.ThumbY, _ = strconv.Atoi(
                    strings.Replace(tsSplit[3], "px;", "", -1))
                continue
            }

            if postBody && token.Data == "br" {
                fmt.Fprint(buf, "\n")
            }
        }
    }
    // Finalize last post
    if postTime != "" { // TODO: Hack for /htmlnew/ missing data-utc. Remove.
        current.Time, _ = time.Parse(yotsuba.YotsubaTime+"MST", postTime+"EST")
        postTime = ""
    }
    posts = append(posts, current)
    thread.Posts = posts

    if !complete {
        return nil, errors.New("Incomplete thread page")
    }
    return thread, nil
}
